PHP 充血模型架构学习
以现场开盘的后台为例:
基础服务层
middlend/common/BaseController.php
基础服务层没啥好说,就是把一般的通用操作,校验登录态,鉴权,记录访问日志之类的通用操作
执行前:beforeAction
0、记录请求日志 1、检查 CSRF 跨站请求伪造 2、校验登录态,如果是访客模式或者未取得用户,则跳转到登录页
middlend/models/User.php
3、加载基类中的 roleAuthRules 方法,并验证权限点
- action 就是请求的方法名称。e.g. AddActivity
- 如果未写到 actions 的 Controller 方法不校验
注:如果是在线开盘,这里还有一个切换到拆库的逻辑
执行后:afterAction
记录响应日志
UI 层
middlend/controllers/xckp/ActivityController.php
/**
* 编辑开盘活动
*/
public function actionEditActivity()
{
$this->exitIfNotPost();
$form = new ActivityEditForm();
$form->load(Yii::$app->request->post());
AjaxResult::json($form->editActivity());
}
把参数装载到 Form 中(它承接了 DTO 的操作),通过 rules 完成参数的绑定与参数校验
middlend/models/xckp/activity/ActivityEditForm.php
同时这个 Form 承接传统的 Controller 的职责,去调用对应的 Service 完成工作
中间胶水层
PHP 的这个架构设计的有点特殊,它通过一个 Repository 来完成类似于 SpringBoot 那样的自动注入操作
// common/repository/xckp/ActivityRepository.php
// 内部实际的构造方法可以看出它实际上包含了 Model 层、Service 层、一个 DTO
public static function createInstance()
{
if (self::$instance === null) {
self::$instance = new self(
ActivityService::className(),
Activity::className(),
ActivityDTO::className()
);
}
return self::$instance;
}
可以看到与上面的 From 每次都创建一个新的实例不同,这个 ActivityRepository 是一个单例。
注意:PHP 这个语言有点特殊,它每一个请求实际都是通过 php-fpm 服务去创建的进程,而这个进程请求结束后就被释放了,所以它就没办法像 Java 那样简单的把单例挂在当前服务的内存里面,下次调用时直接从内存中再次取得数据。所以这个单例是在一个请求内的,下次请求还会创建
首先看一下上面的 UI 层是怎样去调用这个胶水层的?
// 取得实际的服务层
private function getActivityService()
{
$activityService = ActivityRepository::getInstance()->getById($this->activityId);
if ($activityService === null) {
throw (new ActivityNotExistsException());
} else {
return $activityService;
}
}
那为啥不直接对 ActivityService
做单例,而是做个胶水层的单例呢?
首先我们来探究一下在 SpringBoot 中的 Service 是如何工作的?在 SpringBoot 中的单例并不保持状态,一切的数据都是通过方法传递进去执行的后再返回出来的,这里核心就在于这个 Service 不保存状态,它传输的数据也是贫血模型。
但是我们的架构并不是这样设计的。PHP 的 Service 通过桥接的方式内嵌了一个 DTO 去保存了状态:
class ActivityService extends RefactorBaseService implements ISmsSource {
/**
* @var ActivityDTO
*/
protected $dto;
// ....
所以实际上我们的这个 Service 是一个充血模型,即 Service 下面的方法实际上都是在操作这个 DTO,因此需要通过一个机制去绑定一个 DTO 到 Service 里面,同理另一个 Activity 实际上也是一个操作持久层的充血模型(Model),它也是保存了当前实体的状态。
基于上面的两个充血模型需要互相转换,因此可以将他们绑定在一起,就无需单独的做类型转换了。
服务层
common/services/xckp/activity/ActivityDTO.php
common/services/xckp/activity/ActivityService.php
这个服务层和 DTO 是通过桥接的方式绑定在一起的充血模型,所以它的操作实际上是作用于自己身上的,更有一种面向对象的风格,即我作用于自己,而不是靠另一个无状态的层去操作自己
持久层
common/models/xckp/Activity.php
持久层同上
知识补充说明:
DTO 就是典型的贫血模型,里面只有数据,没有一星半点的业务逻辑。贫血模型适合使用的场景就是承载和传递数据。
贫血模型的对立面就是充血模型了,也就是说,类型成员中除了承载数据的属性外,还有和其职责相关的方法。有的甚至在属性的 get 或 set 方法里面写上一些数据校验或数据转换的逻辑。
充血模型其实很简单,就是面向对象设计的本质:“一个对象是拥有状态和行为的”,比如说一个人,他眼睛什么样鼻子什么样这就是状态,人可以去打游戏或是写程序,这就是行为。
贫血模型最早广泛应用是源自于 EJB2,最强盛时期则是由 Spring 创造,把“行为”(也称为逻辑、过程)和“状态”(可理解为数据,对应到语言就是对象成员变量)分离到不同的对象之中,那个只有状态的对象就是所谓的“贫血对象”(常称为VO——Value Object),而那个只有行为的对象就是我们常见的 N 层结构中的 Logic/Service/Manager 层(对应到EJB2中的Stateless Session Bean)。(曾经Spring的作者Rod Johnson也承认,Spring不过是在沿袭EJB2时代的“事务脚本”,也就是面向过程编程)
贫血模型
此种模型下领域对象的作用很简单,只有所有属性的 get/set 方式,以及少量简单的属性值转换,不包含任何业务逻辑,不关系对象持久化,只是用来做为数据对象的承载和传递的介质。
@Entity
@Data
@ToString
@AllArgsConstructor
@NoArgsConstructor
public class User {
@Id
private String userId;
private String userName;
private String password;
private boolean isLock;
}
而真正的业务逻辑则由领域服务负责实现,此服务引入持久化仓库,在业务逻辑完成之后持久化到仓库中,并在此可以发布领域事件(Domain Event)
public interface UserService {
void create(User user);
void edit(User user);
void changePassword(String userId, String newPassword);
void lock(String userId);
void unlock(String userId);
}
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserRepository repo;
@Override
public void edit(User user) {
User dbUser = repo.findById(user.getUserId()).get();
dbUser.setUserName(user.getUserName());
repo.save(dbUser);
// 发布领域事件 ...
}
@Override
public void lock(String userId) {
User dbUser = repo.findById(userId).get();
dbUser.setLock(true);
repo.save(dbUser);
// 发布领域事件 ...
}
// ... 省略完整代码
}
优点: 结构简单,职责单一,相互隔离性好,使用单例模型提高运行性能
缺点: 对象状态与行为分离,不能直观地描述领域对象。行为的设计主要考虑参数的输入和输出而非行为本身,不太具有面向对象设计的思考方式。行为间关联性较小,更像是面向过程式的方法,可复用性也较小。
SpringBoot 采用单例模式,尽量不手动创建对象,对象无状态化,故较推荐使用贫血模型
充血模型
此种模型下领域对象作用此领域相关行为,包含此领域相关的业务逻辑,同时也包含对领域对象的持久化操作。
@Entity
@Data
@Builder
@AllArgsConstructor
public class User implements UserService {
@Id
private String userId;
private String userName;
private String password;
private boolean isLock;
// 持久化仓库
@Transient
private UserRepository repo;
// 是否是持久化对象
@Transient
private boolean isRepository;
@PostLoad
public void per() {
isRepository = true;
}
public User() {
}
public User(UserRepository repo) {
this.repo = repo;
}
@Override
public void create(User user) {
repo.save(user);
}
@Override
public void edit(User user) {
if (!isRepository) {
throw new RuntimeException("用户不存在");
}
userName = user.userName;
repo.save(this);
// 发布领域事件 ...
}
@Override
public void lock() {
if (!isRepository) {
throw new RuntimeException("用户不存在");
}
isLock = true;
repo.save(this);
// 发布领域事件 ...
}
}
优点: 对象自洽程度很高,表达能力很强,因此非常适合于复杂的企业业务逻辑的实现,以及可复用程度比较高,更符合面向对象设计思想
缺点: 对象属性中掺杂持久化仓库,不够纯粹,持久化操作是否属于业务逻辑有待求证。但由于持久化仅需暴露接口,对业务逻辑与持久化操作的耦合度有一定降低。
说明: 有人认为对象中的 Create()
,是新建对象方法不应该属于对象本身,应由其它对象产生或 static 方法产生。我的理解是不能把业务对象中的新建和程序对象上的新建混淆。业务对象的新建是指的是业务行为操作得出的结果,理应属于对象本身行为。而程序里的新建则是对象初始化过程 New()
,这是程序构建逻辑不是业务概念,不能相等对待。
在领域对象行为逻辑较复杂的情况下,需要多个行为共享对象状态的时候,充血模型表现力更强,个人比较推荐此种模型